D:\a\csshw\csshw\src\cli.rs
Line | Count | Source |
1 | | //! CLI interface |
2 | | |
3 | | use crate::client::main as client_main; |
4 | | use crate::daemon::{main as daemon_main, resolve_cluster_tags}; |
5 | | use crate::utils::config::{ClientConfig, Cluster, Config, ConfigOpt, DaemonConfig}; |
6 | | use crate::utils::windows::WindowsApi; |
7 | | use crate::{ |
8 | | get_console_window_handle, init_logger, is_launched_from_gui, spawn_console_process, |
9 | | WindowsSettingsDefaultTerminalApplicationGuard, |
10 | | }; |
11 | | use clap::{ArgAction, CommandFactory, Parser, Subcommand}; |
12 | | |
13 | | #[cfg(test)] |
14 | | use mockall::{automock, predicate::*}; |
15 | | use windows::Win32::UI::HiDpi::PROCESS_PER_MONITOR_DPI_AWARE; |
16 | | |
17 | | const PKG_NAME: &str = env!("CARGO_PKG_NAME"); |
18 | | |
19 | | /// Cluster SSH tool for Windows inspired by csshX |
20 | | /// |
21 | | /// The main CLI arguments |
22 | | #[derive(Parser, Debug)] |
23 | | #[clap(author, version, about, long_about = None)] |
24 | | pub struct Args { |
25 | | /// Optional subcommand |
26 | | /// Usually not specified by the user |
27 | | #[clap(subcommand)] |
28 | | command: Option<Commands>, |
29 | | /// Optional username used to connect to the hosts |
30 | | #[clap(long, short = 'u')] |
31 | | username: Option<String>, |
32 | | /// Optional port used for all SSH connections |
33 | | #[clap(long, short = 'p')] |
34 | | port: Option<u16>, |
35 | | /// Hosts and/or cluster tag(s) to connect to |
36 | | /// |
37 | | /// Hosts or cluster tags might use brace expansion, |
38 | | /// but need to be properly quoted. |
39 | | /// |
40 | | /// E.g.: `csshw.exe "host{1..3}" hostA` |
41 | | /// |
42 | | /// Hosts can include a username which will take precedence over the |
43 | | /// username given via the `-u` option and over any ssh config value. |
44 | | /// |
45 | | /// E.g.: `csshw.exe -u user3 user1@host1 userA@hostA host3` |
46 | | /// |
47 | | /// Hosts can include a port number which will take precedence over the |
48 | | /// port given via the `-p` option. |
49 | | /// |
50 | | /// E.g.: `csshw.exe -p 33 host1:11 host2:22 host3` |
51 | | /// |
52 | | /// If no hosts are provided and the application is launched in a new console window |
53 | | /// (e.g. by double clicking the executable in the File Explorer), |
54 | | /// it will launch in interactive mode. |
55 | | #[clap(required = false, global = true)] |
56 | | hosts: Vec<String>, |
57 | | /// Enable extensive logging |
58 | | #[clap(short, long, action=ArgAction::SetTrue)] |
59 | | debug: bool, |
60 | | } |
61 | | |
62 | | /// The ``command`` CLI subcommand |
63 | | #[derive(Debug, Subcommand, PartialEq)] |
64 | | enum Commands { |
65 | | /// Subcommand that will launch a single client window |
66 | | /// |
67 | | /// connecting to the given host with the given username. |
68 | | /// It will also try to read input from a daemon via the named pipe. |
69 | | Client { |
70 | | /// Host to connect to |
71 | | host: String, |
72 | | }, |
73 | | /// Subcommand that will launch the daemon window. |
74 | | /// |
75 | | /// The daemon is responsible to launch the client windows, |
76 | | /// one for each given host. |
77 | | /// For each client a named pipe will be created and any keystrokes |
78 | | /// the daemon window receives are forwarded via the pipes to all the clients. |
79 | | /// Also handles control mode. |
80 | | Daemon {}, |
81 | | } |
82 | | |
83 | | /// Main Entrypoint struct |
84 | | /// |
85 | | /// Used to implement the entrypoint functions of the different |
86 | | /// subcommands |
87 | | pub struct MainEntrypoint; |
88 | | |
89 | | /// Trait for Args operations to enable mocking in tests |
90 | | #[cfg_attr(test, automock)] |
91 | | pub trait ArgsCommand { |
92 | | /// Print help message |
93 | | fn print_help(&self) -> Result<(), std::io::Error>; |
94 | | } |
95 | | |
96 | | /// Default implementation of ArgsCommand trait |
97 | | pub struct CLIArgsCommand; |
98 | | |
99 | | impl ArgsCommand for CLIArgsCommand { |
100 | 0 | fn print_help(&self) -> Result<(), std::io::Error> { |
101 | 0 | return Args::command().print_help(); |
102 | 0 | } |
103 | | } |
104 | | |
105 | | /// Trait for logger initialization to enable mocking in tests |
106 | | #[cfg_attr(test, automock)] |
107 | | pub trait LoggerInitializer { |
108 | | /// Initialize logger with the given name |
109 | | fn init_logger(&self, name: &str); |
110 | | } |
111 | | |
112 | | /// Default implementation of LoggerInitializer trait |
113 | | pub struct CLILoggerInitializer; |
114 | | |
115 | | impl LoggerInitializer for CLILoggerInitializer { |
116 | 0 | fn init_logger(&self, name: &str) { |
117 | 0 | init_logger(name); |
118 | 0 | } |
119 | | } |
120 | | |
121 | | /// Trait defining the entrypoint functions of the different |
122 | | /// subcommands |
123 | | #[cfg_attr(test, automock)] |
124 | | pub trait Entrypoint { |
125 | | /// Entrypoint for the client subcommand |
126 | | fn client_main<W: WindowsApi + 'static>( |
127 | | &mut self, |
128 | | windows_api: &W, |
129 | | host: String, |
130 | | username: Option<String>, |
131 | | port: Option<u16>, |
132 | | config: &ClientConfig, |
133 | | ) -> impl std::future::Future<Output = ()> + Send; |
134 | | /// Entrypoint for the daemon subcommand |
135 | | fn daemon_main<W: WindowsApi + Clone + 'static>( |
136 | | &mut self, |
137 | | windows_api: &W, |
138 | | hosts: Vec<String>, |
139 | | username: Option<String>, |
140 | | port: Option<u16>, |
141 | | config: &DaemonConfig, |
142 | | clusters: &[Cluster], |
143 | | debug: bool, |
144 | | ) -> impl std::future::Future<Output = ()> + Send; |
145 | | /// Entrypoint for the main command |
146 | | fn main<W: WindowsApi + 'static>( |
147 | | &mut self, |
148 | | windows_api: &W, |
149 | | config_path: &str, |
150 | | config: &Config, |
151 | | args: Args, |
152 | | ); |
153 | | } |
154 | | |
155 | | impl Entrypoint for MainEntrypoint { |
156 | 0 | async fn client_main<W: WindowsApi>( |
157 | 0 | &mut self, |
158 | 0 | windows_api: &W, |
159 | 0 | host: String, |
160 | 0 | username: Option<String>, |
161 | 0 | port: Option<u16>, |
162 | 0 | config: &ClientConfig, |
163 | 0 | ) { |
164 | 0 | client_main(windows_api, host, username, port, config).await; |
165 | 0 | } |
166 | | |
167 | 0 | async fn daemon_main<W: WindowsApi + Clone + 'static>( |
168 | 0 | &mut self, |
169 | 0 | windows_api: &W, |
170 | 0 | hosts: Vec<String>, |
171 | 0 | username: Option<String>, |
172 | 0 | port: Option<u16>, |
173 | 0 | config: &DaemonConfig, |
174 | 0 | clusters: &[Cluster], |
175 | 0 | debug: bool, |
176 | 0 | ) { |
177 | 0 | daemon_main(windows_api, hosts, username, port, config, clusters, debug).await; |
178 | 0 | } |
179 | | |
180 | 0 | fn main<W: WindowsApi + 'static>( |
181 | 0 | &mut self, |
182 | 0 | windows_api: &W, |
183 | 0 | config_path: &str, |
184 | 0 | config: &Config, |
185 | 0 | args: Args, |
186 | 0 | ) { |
187 | 0 | confy::store_path(config_path, config).unwrap(); |
188 | | |
189 | 0 | let mut daemon_args: Vec<String> = Vec::new(); |
190 | 0 | if args.debug { |
191 | 0 | daemon_args.push("-d".to_string()); |
192 | 0 | } |
193 | 0 | if let Some(username) = args.username { |
194 | 0 | daemon_args.push("-u".to_string()); |
195 | 0 | daemon_args.push(username); |
196 | 0 | } |
197 | 0 | if let Some(port) = args.port { |
198 | 0 | daemon_args.push("-p".to_string()); |
199 | 0 | daemon_args.push(port.to_string()); |
200 | 0 | } |
201 | 0 | daemon_args.push("daemon".to_string()); |
202 | | // Order is important here. If the hosts are passed before the daemon subcommand |
203 | | // it will not be recognizes as such and just be passed along as one of the hosts. |
204 | 0 | daemon_args.extend( |
205 | 0 | resolve_cluster_tags( |
206 | 0 | args.hosts.iter().map(|host| return &**host).collect(), |
207 | 0 | &config.clusters, |
208 | | ) |
209 | 0 | .into_iter() |
210 | 0 | .map(|host| return host.to_string()), |
211 | | ); |
212 | 0 | let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new(); |
213 | | // We must wait for the window to actually launch before dropping the _guard as we might otherwise |
214 | | // reset the configuration before the window was launched |
215 | 0 | let _ = get_console_window_handle( |
216 | 0 | windows_api, |
217 | 0 | spawn_console_process(windows_api, &format!("{PKG_NAME}.exe"), daemon_args) |
218 | 0 | .expect("Failed to create process") |
219 | 0 | .dwProcessId, |
220 | 0 | ); |
221 | 0 | } |
222 | | } |
223 | | |
224 | | /// Display the interactive mode prompt and instructions |
225 | 0 | fn show_interactive_prompt() { |
226 | 0 | println!("\n=== Interactive Mode ==="); |
227 | 0 | println!("Enter your {PKG_NAME} arguments (or press Enter to exit):"); |
228 | 0 | println!("Example: -u myuser host1 host2 host3"); |
229 | 0 | println!("Example: --help"); |
230 | 0 | print!("> "); |
231 | 0 | std::io::Write::flush(&mut std::io::stdout()).unwrap(); |
232 | 0 | } |
233 | | |
234 | | /// Read user input from stdin |
235 | | /// |
236 | | /// # Returns |
237 | | /// |
238 | | /// * `Ok(Some(input))` - User provided input |
239 | | /// * `Ok(None)` - User wants to exit (empty input or "exit") |
240 | | /// * `Err(error)` - Error reading input |
241 | 0 | fn read_user_input() -> Result<Option<String>, std::io::Error> { |
242 | 0 | let mut input = String::new(); |
243 | 0 | std::io::stdin().read_line(&mut input)?; |
244 | | |
245 | 0 | let input = input.trim(); |
246 | 0 | if input.is_empty() || input.to_lowercase() == "exit" { |
247 | 0 | return Ok(None); |
248 | 0 | } |
249 | | |
250 | 0 | return Ok(Some(input.to_string())); |
251 | 0 | } |
252 | | |
253 | | /// Handle special commands that don't need full parsing |
254 | | /// |
255 | | /// # Arguments |
256 | | /// |
257 | | /// * `input` - The user input string |
258 | | /// * `args_command` - The ArgsCommand trait object for printing help |
259 | | /// |
260 | | /// # Returns |
261 | | /// |
262 | | /// * `true` - Command was handled, continue loop |
263 | | /// * `false` - Command needs full parsing |
264 | 9 | fn handle_special_commands<A: ArgsCommand>(input: &str, args_command: &A) -> bool { |
265 | 9 | if input == "--help" || input == "-h"8 { |
266 | 2 | let _ = args_command.print_help(); |
267 | 2 | return true; |
268 | 7 | } |
269 | 7 | return false; |
270 | 9 | } |
271 | | |
272 | | /// Execute a parsed command using the provided entrypoint |
273 | 4 | async fn execute_parsed_command< |
274 | 4 | W: WindowsApi + Clone + 'static, |
275 | 4 | T: Entrypoint, |
276 | 4 | A: ArgsCommand, |
277 | 4 | L: LoggerInitializer, |
278 | 4 | >( |
279 | 4 | windows_api: &W, |
280 | 4 | parsed_args: Args, |
281 | 4 | entrypoint: &mut T, |
282 | 4 | args_command: &A, |
283 | 4 | logger_initializer: &L, |
284 | 4 | config: &Config, |
285 | 4 | config_path: &str, |
286 | 4 | ) { |
287 | 2 | match &parsed_args.command { |
288 | 1 | Some(Commands::Client { host }) => { |
289 | 1 | if parsed_args.debug { |
290 | 0 | logger_initializer.init_logger(&format!("csshw_client_{host}")); |
291 | 1 | } |
292 | 1 | entrypoint |
293 | 1 | .client_main( |
294 | 1 | windows_api, |
295 | 1 | host.to_owned(), |
296 | 1 | parsed_args.username.to_owned(), |
297 | 1 | parsed_args.port, |
298 | 1 | &config.client, |
299 | 1 | ) |
300 | 1 | .await; |
301 | | } |
302 | | Some(Commands::Daemon {}) => { |
303 | 1 | if parsed_args.debug { |
304 | 1 | logger_initializer.init_logger("csshw_daemon"); |
305 | 1 | }0 |
306 | 1 | entrypoint |
307 | 1 | .daemon_main( |
308 | 1 | windows_api, |
309 | 1 | parsed_args.hosts, |
310 | 1 | parsed_args.username, |
311 | 1 | parsed_args.port, |
312 | 1 | &config.daemon, |
313 | 1 | &config.clusters, |
314 | 1 | parsed_args.debug, |
315 | 1 | ) |
316 | 1 | .await; |
317 | | } |
318 | | None => { |
319 | 2 | if !parsed_args.hosts.is_empty() { |
320 | 1 | entrypoint.main(windows_api, config_path, config, parsed_args); |
321 | 1 | } else { |
322 | 1 | // Show help for empty hosts |
323 | 1 | let _ = args_command.print_help(); |
324 | 1 | } |
325 | | } |
326 | | } |
327 | 4 | } |
328 | | |
329 | | /// Run the interactive mode loop for GUI launches |
330 | 0 | async fn run_interactive_mode<W: WindowsApi + Clone + 'static, T: Entrypoint>( |
331 | 0 | windows_api: &W, |
332 | 0 | mut entrypoint: T, |
333 | 0 | config: &Config, |
334 | 0 | config_path: &str, |
335 | 0 | ) { |
336 | | loop { |
337 | 0 | show_interactive_prompt(); |
338 | | |
339 | 0 | match read_user_input() { |
340 | 0 | Ok(Some(input)) => { |
341 | | // Handle special commands first |
342 | 0 | if handle_special_commands(&input, &CLIArgsCommand) { |
343 | 0 | continue; |
344 | 0 | } |
345 | | |
346 | | // Parse the input as command line arguments |
347 | 0 | let input_args: Vec<&str> = input.split_whitespace().collect(); |
348 | 0 | let mut full_args = vec![PKG_NAME]; |
349 | 0 | full_args.extend(input_args); |
350 | | |
351 | 0 | match Args::try_parse_from(full_args) { |
352 | 0 | Ok(parsed_args) => { |
353 | 0 | execute_parsed_command( |
354 | 0 | windows_api, |
355 | 0 | parsed_args, |
356 | 0 | &mut entrypoint, |
357 | 0 | &CLIArgsCommand, |
358 | 0 | &CLILoggerInitializer, |
359 | 0 | config, |
360 | 0 | config_path, |
361 | 0 | ) |
362 | 0 | .await; |
363 | | } |
364 | 0 | Err(err) => { |
365 | 0 | eprintln!("\nError parsing arguments: {err}"); |
366 | 0 | } |
367 | | } |
368 | | } |
369 | | Ok(None) => { |
370 | 0 | return; |
371 | | } |
372 | 0 | Err(err) => { |
373 | 0 | eprintln!("Error reading input: {err}"); |
374 | 0 | } |
375 | | } |
376 | | } |
377 | 0 | } |
378 | | |
379 | | /// The main entrypoint |
380 | | /// |
381 | | /// Parses the CLI arguments, |
382 | | /// loads an existing config or writes the default config to disk, and |
383 | | /// calls the respective subcommand. |
384 | | /// If no subcommand is given we launch the daemon subcommand in a new window. |
385 | 3 | pub async fn main<W: WindowsApi + Clone + 'static, E: Entrypoint>( |
386 | 3 | windows_api: &W, |
387 | 3 | args: Args, |
388 | 3 | mut entrypoint: E, |
389 | 3 | ) { |
390 | | // CRITICAL: Check GUI launch BEFORE any output to console |
391 | 3 | let launched_from_gui = is_launched_from_gui(windows_api); |
392 | | |
393 | | // Set DPI awareness programatically. Using the manifest is the recommended way |
394 | | // but conhost.exe does not do any manifest loading. |
395 | | // https://github.com/microsoft/terminal/issues/18464#issuecomment-2623392013 |
396 | 3 | if let Err(err0 ) = windows_api.set_process_dpi_awareness(PROCESS_PER_MONITOR_DPI_AWARE) { |
397 | 0 | eprintln!("Failed to set DPI awareness programatically: {err:?}"); |
398 | 3 | } |
399 | 3 | match std::env::current_exe() { |
400 | 3 | Ok(path) => match path.parent() { |
401 | 0 | None => { |
402 | 0 | eprintln!("Failed to get executable path parent working directory"); |
403 | 0 | } |
404 | 3 | Some(exe_dir) => { |
405 | 3 | std::env::set_current_dir(exe_dir) |
406 | 3 | .expect("Failed to change current working directory"); |
407 | 3 | } |
408 | | }, |
409 | 0 | Err(_) => { |
410 | 0 | eprintln!("Failed to get executable directory"); |
411 | 0 | } |
412 | | } |
413 | | |
414 | 3 | let config_path = format!("{PKG_NAME}-config.toml"); |
415 | 3 | let config_on_disk: ConfigOpt = confy::load_path(&config_path).unwrap(); |
416 | 3 | let config: Config = config_on_disk.into(); |
417 | | |
418 | 2 | match &args.command { |
419 | 1 | Some(Commands::Client { host }) => { |
420 | 1 | if args.debug { |
421 | 0 | init_logger(&format!("csshw_client_{host}")); |
422 | 1 | } |
423 | 1 | entrypoint |
424 | 1 | .client_main( |
425 | 1 | windows_api, |
426 | 1 | host.to_owned(), |
427 | 1 | args.username.to_owned(), |
428 | 1 | args.port, |
429 | 1 | &config.client, |
430 | 1 | ) |
431 | 1 | .await; |
432 | | } |
433 | | Some(Commands::Daemon {}) => { |
434 | 1 | if args.debug { |
435 | 0 | init_logger("csshw_daemon"); |
436 | 1 | } |
437 | 1 | entrypoint |
438 | 1 | .daemon_main( |
439 | 1 | windows_api, |
440 | 1 | args.hosts.to_owned(), |
441 | 1 | args.username.clone(), |
442 | 1 | args.port, |
443 | 1 | &config.daemon, |
444 | 1 | &config.clusters, |
445 | 1 | args.debug, |
446 | 1 | ) |
447 | 1 | .await; |
448 | | } |
449 | | None => { |
450 | | // If no hosts provided, show help and handle GUI vs console launch |
451 | 1 | if args.hosts.is_empty() { |
452 | | // Show help using clap's built-in help |
453 | 0 | Args::command().print_help().unwrap(); |
454 | | |
455 | | // If launched from GUI, allow user to input arguments interactively |
456 | 0 | if launched_from_gui { |
457 | 0 | run_interactive_mode(windows_api, entrypoint, &config, &config_path).await; |
458 | 0 | } |
459 | 0 | return; |
460 | 1 | } |
461 | | |
462 | 1 | entrypoint.main(windows_api, &config_path, &config, args); |
463 | | } |
464 | | } |
465 | 3 | } |
466 | | |
467 | | #[cfg(test)] |
468 | | #[path = "./tests/test_cli.rs"] |
469 | | mod test_cli; |